[WIP][Pro] React 19.2 Partial Prerendering (PPR) — closes #3244#3245
[WIP][Pro] React 19.2 Partial Prerendering (PPR) — closes #3244#3245AbanoubGhadban wants to merge 2 commits into
Conversation
Adds ppr_react_component helper that builds a static shell once (Phase A,
prerenderToNodeStream + AbortController), caches it in Rails.cache, then
streams shell + resume chunks (Phase B, resumeToPipeableStream) on every
hit. Components inside <Suspense> boundaries can call usePostpone() to
declare themselves dynamic; siblings that resolve before the abort timer
land in the cached shell.
Implementation details negotiated with codex over 5 design iterations
(see .claude/docs/ppr/design-v[1-5].md):
- JS capability (proPPR.ts) + AsyncLocalStorage phase tracking
- Lazy-loaded React PPR APIs (peer dep is react>=16; APIs only exist in 19.2+)
- RSC component marker (Symbol.for) so PPR refuses RSC components in v1
- Surgical streaming? predicate updates: html_or_rsc_streaming? for
consumers that should NOT engage on PPR resume
- VM globals: AbortController, AbortSignal, AsyncLocalStorage injected
unconditionally (independent of supportModules) in node-renderer
- Cache shape: {shell_html, postponed_state, console_replay_script,
ppr_version} in Rails.cache; bundle-digest-keyed
- PPR registered in ReactOnRails.node.ts only; not in RSC bundle
Dummy app demo pages (PPRComprehensiveDemo, PPRStaticOnlyDemo,
PPRAllDynamicDemo) exercise multiple Suspense boundaries with mixed
fast/slow/postponed content, fully-static, and all-dynamic edge cases.
Unit test (tests/proPPR.test.tsx) verifies the prerender→resume cycle:
shell contains static content + postponed-boundary placeholders, resume
fills the holes with per-request data, fully-static page short-circuits.
TypeScript and tests pass clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 issues from implementation review (codex), all addressed:
1. PPR can hang under stub timers — check for real setTimeout/clearTimeout in
checkPPRRuntimeOrThrow and fail fast with a clear error pointing to
RENDERER_STUB_TIMERS=false. (proPPR.ts)
2. Corrupt postponedState JSON committed shell before failure — parse +
validate pprPostponedState BEFORE the first writeChunk. On JSON.parse
failure, surface as a pre-stream error so Rails can replace the response.
(proPPR.ts)
3. Resume pre-stream errors ignored throwJsErrors — both pre-shell and
post-shell catches now honor options.throwJsErrors. (proPPR.ts)
4. Malformed prerender result could cache empty shell — internal_ppr_prerender
now hard-validates result.is_a?(Hash) and result.key?('pprShellHtml')
before returning. Failures raise so Rails.cache.fetch's block does NOT
write. (react_on_rails_pro_helper.rb)
5. Cache key didn't vary on prerender_timeout_ms — different timeouts produce
different sets of resolved-vs-postponed boundaries, so they need
independent cache entries. PPR.cache_key now includes the effective
timeout. (ppr.rb, react_on_rails_pro_helper.rb)
Tests still pass; Rails dummy app still demonstrates 36x speedup
(5.9s cold → 0.16s warm hit) with dynamic timestamps changing per request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| def ppr_all_dynamic_demo | ||
| stream_view_containing_react_components(template: "/pages/ppr_all_dynamic_demo") | ||
| end | ||
|
|
There was a problem hiding this comment.
Bug: this clears the entire application cache, not just PPR entries.
Rails.cache.clear evicts session data, fragment caches, and any other cached content sharing the same store. In a shared development environment (or if this route is ever reachable in staging) it becomes a silent DoS against every other cache consumer.
Scope the invalidation to PPR keys instead:
| def ppr_demo_clear_cache | |
| # Clear only PPR cache entries by scanning for our namespace prefix. | |
| # Rails.cache.clear would evict unrelated caches (sessions, fragments, etc.). | |
| if Rails.cache.respond_to?(:delete_matched) | |
| Rails.cache.delete_matched(/\Aror_pro_ppr-v/) | |
| else | |
| Rails.logger.warn "[PPR] Cache store does not support delete_matched; skipping selective clear." | |
| end | |
| head :no_content | |
| end |
Alternatively, store PPR keys in a dedicated cache store (e.g. :ppr_store) and call .clear on that.
size-limit report 📦
|
| const onError = (err: unknown) => { | ||
| // Expected AbortError during normal abort flow — swallow. | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| if (msg.includes('aborted') || msg.includes('AbortError')) return; |
There was a problem hiding this comment.
Fragile AbortError detection: silently swallows any error whose message contains "aborted".
A legitimate component error like new Error("request aborted by user") or new Error("AbortError: network timeout") will be suppressed here, hiding real bugs during prerender.
Prefer checking the abort signal's state first (it's already in scope), then fall back to the standard DOMException name:
| if (msg.includes('aborted') || msg.includes('AbortError')) return; | |
| // Expected abort flow — swallow when the abort signal fired; also match the | |
| // standard DOMException name as a fallback for environments where the error | |
| // doesn't cross the instanceof boundary cleanly. | |
| if (controller.signal.aborted) return; | |
| if (err instanceof Error && err.name === 'AbortError') return; |
| } catch (e) { | ||
| // POST-shell error (after writeChunk): the shell is already on the wire so we can't | ||
| // redirect to a fresh error page. Surface via the chunk pipeline. Honor throwJsErrors so | ||
| // tests / strict consumers see the failure rather than a partial render. | ||
| const error = convertToError(e); | ||
| renderState.hasErrors = true; | ||
| renderState.error = error; | ||
| if (options.throwJsErrors) { | ||
| emitError(error); | ||
| } else { | ||
| const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true }); | ||
| pipeToTransform(errorHtmlStream); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| withPhase('resume', () => { | ||
| runResume().catch((e: unknown) => { | ||
| const error = convertToError(e); | ||
| renderState.hasErrors = true; | ||
| renderState.error = error; | ||
| emitError(error); | ||
| }); |
There was a problem hiding this comment.
Potential double error emission when throwJsErrors is true.
runResume's own catch block calls emitError(error) when throwJsErrors is true (line 361), which consumes the error. But if emitError itself throws (or if another uncaught exception escapes the try block that the inner catch misses), the outer .catch at line 370 fires and calls emitError a second time on the already-closed/errored stream, which can cause write after end or similar stream errors.
runResume exhaustively handles its own errors, so the outer .catch should only exist as a last-resort safety net—it should not re-emitError unconditionally. Consider:
| } catch (e) { | |
| // POST-shell error (after writeChunk): the shell is already on the wire so we can't | |
| // redirect to a fresh error page. Surface via the chunk pipeline. Honor throwJsErrors so | |
| // tests / strict consumers see the failure rather than a partial render. | |
| const error = convertToError(e); | |
| renderState.hasErrors = true; | |
| renderState.error = error; | |
| if (options.throwJsErrors) { | |
| emitError(error); | |
| } else { | |
| const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true }); | |
| pipeToTransform(errorHtmlStream); | |
| } | |
| } | |
| }; | |
| withPhase('resume', () => { | |
| runResume().catch((e: unknown) => { | |
| const error = convertToError(e); | |
| renderState.hasErrors = true; | |
| renderState.error = error; | |
| emitError(error); | |
| }); | |
| withPhase('resume', () => { | |
| // runResume handles all of its own errors. The outer catch is a last-resort guard | |
| // for unanticipated throws that escape the try/catch inside runResume (e.g., if | |
| // emitError itself throws). Avoid calling emitError again if the stream may already | |
| // be closed. | |
| runResume().catch((e: unknown) => { | |
| const error = convertToError(e); | |
| renderState.hasErrors = true; | |
| renderState.error = error; | |
| // Only emit if the stream hasn't been closed yet; double-emitting causes | |
| // "write after end" errors on the underlying PassThrough. | |
| try { emitError(error); } catch { /* stream already closed */ } | |
| }); | |
| }); |
| const handle = (setT as (cb: () => void, ms: number) => unknown)(() => {}, 0); | ||
| // Stubbed timers in the node renderer return undefined. | ||
| if (handle === undefined || handle === null) missing.push('setTimeout (real, not stubbed)'); | ||
| else (clearT as (h: unknown) => void)(handle); |
There was a problem hiding this comment.
Fragile stub-timer detection: the undefined return value is an undocumented implementation detail.
This check assumes the Pro node renderer's stubbed setTimeout returns undefined. If the stub is ever changed to return 0 or null (both falsy but not undefined/null), real timers would be incorrectly flagged as stubbed. Similarly, a real Node.js Timeout object with a zero timeout fires almost immediately, so the timer scheduled here actually runs — leaking a no-op tick into the event loop.
A more robust approach is to explicitly check for the known RENDERER_STUB_TIMERS environment variable that already guards this behavior, or to treat the check as informational (a warning rather than a hard throw) since the actual failure mode (timer never fires) will be caught by the outer prerender timeout anyway:
| const handle = (setT as (cb: () => void, ms: number) => unknown)(() => {}, 0); | |
| // Stubbed timers in the node renderer return undefined. | |
| if (handle === undefined || handle === null) missing.push('setTimeout (real, not stubbed)'); | |
| else (clearT as (h: unknown) => void)(handle); | |
| // Check if we're in stub-timer mode by inspecting the renderer env variable that controls it. | |
| // Probing setTimeout's return value is fragile; the env var is the authoritative signal. | |
| if ( | |
| typeof process !== 'undefined' && | |
| (process.env['RENDERER_STUB_TIMERS'] === 'true' || process.env['RENDERER_STUB_TIMERS'] === '1') | |
| ) { | |
| missing.push('setTimeout (real, not stubbed — set RENDERER_STUB_TIMERS=false)'); | |
| } |
| return { | ||
| html: shellHtml, | ||
| pprShellHtml: shellHtml, | ||
| pprPostponedState: postponed ? JSON.stringify(postponed) : null, | ||
| consoleReplayScript, | ||
| hasErrors: false, | ||
| isShellReady: true, | ||
| }; |
There was a problem hiding this comment.
shellHtml is serialized twice in the prerender response.
Both html and pprShellHtml carry the same string. For large pages (marketing pages with thousands of nodes are common PPR candidates) this doubles the JSON payload sent from the node renderer back to Ruby. The node renderer already JSON-stringifies the whole result object, so the duplication isn't free.
internal_ppr_prerender on the Ruby side reads result["pprShellHtml"] explicitly. If you drop html from the prerender return value, the Pro Ruby helper needs a small update—but that's better than sending every shell twice forever.
At a minimum, omit html from the prerender response type and remove the field here:
| return { | |
| html: shellHtml, | |
| pprShellHtml: shellHtml, | |
| pprPostponedState: postponed ? JSON.stringify(postponed) : null, | |
| consoleReplayScript, | |
| hasErrors: false, | |
| isShellReady: true, | |
| }; | |
| return { | |
| pprShellHtml: shellHtml, | |
| pprPostponedState: postponed ? JSON.stringify(postponed) : null, | |
| consoleReplayScript, | |
| hasErrors: false, | |
| isShellReady: true, | |
| }; |
Update PPRPrerenderResult accordingly and confirm that no other consumer reads .html on the prerender result.
| let cachedAPIError: Error | null = null; | ||
|
|
||
| async function loadPPRReactAPIs() { | ||
| if (cachedAPIs) return cachedAPIs; | ||
| if (cachedAPIError) throw cachedAPIError; |
There was a problem hiding this comment.
Permanent failure on transient errors: once cachedAPIError is set, every future PPR call fails without retry.
The intent is good (avoid re-resolving dynamic imports repeatedly), but if the first PPR call happens before React 19.2 is fully available (e.g., during a hot-module-replacement cycle in development, or a race at worker startup), the error is cached permanently and the worker must be restarted to recover.
For the success path, the singleton is correct. For failures, consider only caching hard version errors (React too old) while letting transient module-resolution errors retry:
| let cachedAPIError: Error | null = null; | |
| async function loadPPRReactAPIs() { | |
| if (cachedAPIs) return cachedAPIs; | |
| if (cachedAPIError) throw cachedAPIError; | |
| let cachedAPIs: { | |
| prerenderToNodeStream: PrerenderToNodeStreamFn; | |
| resumeToPipeableStream: ResumeToPipeableStreamFn; | |
| } | null = null; | |
| // Only cache the error when it's definitively a version mismatch; transient | |
| // resolution failures (e.g. during HMR) can be retried. | |
| let cachedVersionError: Error | null = null; |
Then in the catch:
} catch (e) {
const err = convertToError(e);
// Permanently cache only version errors; other errors (transient HMR races, etc.) can retry.
if (err.message.includes('React on Rails Pro PPR requires React')) {
cachedVersionError = err;
}
throw err;
}| import type { Readable } from 'stream'; | ||
| import { ReadableStream } from 'stream/web'; | ||
| import { promisify, TextEncoder } from 'util'; | ||
| import { AsyncLocalStorage } from 'async_hooks'; |
There was a problem hiding this comment.
Use the node: prefix for built-in module imports.
async_hooks (without prefix) is the legacy form. The canonical modern form is node:async_hooks. The prefixed form is unambiguous (can't be shadowed by a userland package named async_hooks) and is required by some linting rules and bundler configurations.
| import { AsyncLocalStorage } from 'async_hooks'; | |
| import { AsyncLocalStorage } from 'node:async_hooks'; |
| render_function_name = | ||
| if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming? | ||
| if render_options.ppr_prerender? | ||
| "'prerenderReactComponentForPPR'" | ||
| elsif render_options.ppr_resume? | ||
| "'resumeReactComponentForPPR'" | ||
| elsif ReactOnRailsPro.configuration.enable_rsc_support && render_options.html_or_rsc_streaming? | ||
| # Select appropriate function based on whether the rendering request is running on server or rsc bundle | ||
| # As the same rendering request is used to generate the rsc payload and SSR the component. | ||
| "ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'" | ||
| else | ||
| "'serverRenderReactComponent'" | ||
| end |
There was a problem hiding this comment.
PPR render-function dispatch is duplicated verbatim in js_code_builder.rb.
JsCodeBuilder#resolve_render_function_js_expr (lines 116-126 of js_code_builder.rb) contains identical ppr_prerender?/ppr_resume? branches. When a third PPR function is ever added, a developer must update two files. One of these should delegate to the other:
# server_rendering_js_code.rb — delegate to JsCodeBuilder
render_function_name = JsCodeBuilder.new(render_options).resolve_render_function_js_expr(render_options)Or extract a small shared module. The duplication is currently invisible because both files produce the same string, but it will silently diverge on future changes.
| currentTime: Time.current.iso8601(3), | ||
| userName: cookies[:demo_user_name].presence || "Guest", | ||
| cartItemCount: rand(0..9), |
There was a problem hiding this comment.
Demo anti-pattern: request-varying values are passed as top-level props.
currentTime and cartItemCount are evaluated on every Rails request and sent as props to both the prerender phase and the resume phase. In the prerender, the components that read these values call usePostpone(), so the values are correctly ignored during shell construction. But:
-
These values are transmitted to the node renderer on every prerender call—even on cache hits, because
ppr_react_componentre-invokesinternal_ppr_resume(which injects them intorailsContext) on every request. That's fine by design, but the demo makes it look like passing dynamic props toppr_react_componentis the right pattern, when in fact the shell's cache key ("ppr-demo-v1") is fixed and doesn't change with these values. -
rand(0..9)in particular is a trap: if a future developer adds a static boundary that readscartItemCount, they'll get a silently wrong (cached) value.
Consider clarifying the demo comment to make the design contract explicit:
<%# Props here are forwarded to the RESUME phase only. The static shell is cached
independently of these values. Dynamic components must call usePostpone() to
ensure they are excluded from the cached shell. %>
<%= ppr_react_component(
"PPRComprehensiveDemo",
cache_key: ["ppr-demo-v1", request.host],
props: {
currentTime: Time.current.iso8601(3),
userName: cookies[:demo_user_name].presence || "Guest",
cartItemCount: rand(0..9),
},
| ppr_params = if render_options.ppr? | ||
| <<-JS | ||
| railsContext.pprPhase = #{render_options.ppr_prerender? ? '"prerender"' : '"resume"'}; | ||
| railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json}; | ||
| railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json}; | ||
| railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json}; | ||
| JS |
There was a problem hiding this comment.
pprShellHtml is injected via railsContext even during the prerender phase, where it is unused.
The ppr? guard is true for both :ppr_prerender and :ppr_resume. During prerender, pprShellHtml and pprPostponedState are always nil/null, so they serialize to null and waste bandwidth. More importantly, for the resume phase, the shell HTML (which can be hundreds of KB for complex pages) is serialized into the JS code string that is sent as the HTTP request body to the node renderer—effectively transmitting the cached shell back to the renderer that generated it in the first place.
Consider splitting this block and only injecting pprShellHtml/pprPostponedState on resume:
| ppr_params = if render_options.ppr? | |
| <<-JS | |
| railsContext.pprPhase = #{render_options.ppr_prerender? ? '"prerender"' : '"resume"'}; | |
| railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json}; | |
| railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json}; | |
| railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json}; | |
| JS | |
| ppr_params = if render_options.ppr? | |
| phase = render_options.ppr_prerender? ? '"prerender"' : '"resume"' | |
| resume_fields = if render_options.ppr_resume? | |
| <<~JS | |
| railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json}; | |
| railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json}; | |
| JS | |
| else | |
| "" | |
| end | |
| <<~JS | |
| railsContext.pprPhase = #{phase}; | |
| railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json}; | |
| #{resume_fields} | |
| JS | |
| else | |
| "" | |
| end |
Code Review — PPR Implementation (WIP)This is a well-architected draft with a clear design history and good defense-in-depth thinking. The core flow (prerender → cache → resume-stream) is sound, and the five design-review rounds show the hard problems have been thought through carefully. The notes below are about concrete bugs and a few patterns that will cause pain in production. Critical / Must-Fix1.
2. AbortError detection silently swallows unrelated errors (inline comment on
3. Potential double
Important4. Both 5. Shell HTML is injected into The cached shell is serialized into the JS code string sent as the HTTP request body to the node renderer. On a busy site with 50 KB shells, that's 50 KB of HTTP overhead per request. Only inject 6. PPR function dispatch is duplicated in two Ruby files (inline comment on
7. Transient load errors permanently poison the API cache (inline comment on Once 8. Fragile stub-timer detection (inline comment on Detecting stubbed timers by checking whether 9. The legacy Minor / Before Un-draftingDemo: dynamic props look like they're part of the cache contract (inline comment on
Missing test coverage — before un-drafting the test suite should cover:
What's Working Well
🤖 Generated with Claude Code |
Summary
WIP draft implementing the RFC from #3244. Adds
ppr_react_componentPro helper that builds a static HTML shell once (Phase A,prerenderToNodeStream+AbortController), caches it, then streamsshell + resume chunks(Phase B,resumeToPipeableStream) on every hit. Components inside<Suspense>boundaries can callusePostpone()to declare themselves dynamic; siblings that resolve before the abort timer land in the cached shell.End-to-end verified in the Pro dummy app:
Static product names stay identical across requests (cached shell). Dynamic timestamps + cart counts change every request (resume freshness).
What's in this PR
JS —
packages/react-on-rails-pro/src/capabilities/proPPR.ts—prerenderReactComponentForPPRandresumeReactComponentForPPR. Lazy-loaded React PPR APIs (peer dep isreact >= 16; APIs only exist in 19.2+). Runtime checks forAbortController/AsyncLocalStorage/real timers (fails fast ifRENDERER_STUB_TIMERS=true).src/postpone.ts—withPhase+usePostpone.AsyncLocalStorage-based phase tracking with module-level fallback.src/registerServerComponent/server.tsx— RSC wrappers tagged withSymbol.for('react_on_rails_pro.rsc_component')so PPR refuses RSC components in v1 (RSC + PPR composition is intentionally deferred).src/ReactOnRails.node.ts— registers PPR capability only on the Node SSR entry, not the RSC bundle.OSS —
packages/react-on-rails/src/rscMarker.ts— shared marker (Symbol.for stable across module boundaries).lib/.../render_options.rb— adds:ppr_prerender,:ppr_resumerender modes; newhtml_or_rsc_streaming?predicate so consumers that should NOT engage on PPR resume have a clean way to opt out.lib/.../render_request.rb— delegates the new predicates.Pro Ruby —
react_on_rails_pro/lib/.../ppr.rb— cache-key composition (includes bundle digest, cache_key, prerender timeout, PPR cache version) and runtime support check.lib/.../configuration.rb—enable_ppr_support(default false),ppr_prerender_timeout_ms(default 8s).app/helpers/.../ppr_react_component— top-level helper. Hard-validates JS-side response shape (Hash,pprShellHtmlkey) before caching.pro_rendering.rb,server_rendering_js_code.rb,js_code_builder.rbso PPR doesn't trip RSC/stream-cache code paths.Node renderer —
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts—AbortController,AbortSignal,AsyncLocalStorageinjected unconditionally (independent ofsupportModules). NoprotocolVersionbump.Demo & tests
tests/proPPR.test.tsx) — prerender→resume cycle, fully-static fast path.Design rounds with codex
The
.claude/docs/ppr/design-v[1-5].mdfiles in this PR document 5 iterations of design review with codex before any code was written, plus 2 implementation review rounds (verdict: APPROVE). Notable decisions:<RSCRoute>keep usingstream_react_component./bundles/<hash>/renderendpoint with newrender_mode. NoprotocolVersionbump means older renderers aren't broken.withPhaserather than relying on AsyncLocalStorage's automatic propagation alone.React.unstable_postponewas removed before stable.usePostpone()is implemented via the abort-signal mechanism (throw never-resolving promise) — same semantics, public-API only.Known limitations / non-goals for this PR
registerServerComponentare explicitly rejected.use()+ sync components.docs/api/ppr.md+ migration guide are TODO before merging.Test plan
pnpm --filter react-on-rails-pro type-checkcleanpnpm --filter react-on-rails-pro buildcleanpnpm --filter react-on-rails-pro exec jest tests/proPPR.test.tsx(2 tests pass)enable_ppr_support = true,ppr_prerender_timeout_ms = 5000Files changed
37 files, +2473/-15. See diff.
🤖 Generated with Claude Code